Skip to content

Implement #:package manipulation for file-based apps #49635

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from

Conversation

jjonescz
Copy link
Member

@jjonescz jjonescz commented Jul 2, 2025

Part of #49200. This PR implements dotnet package add and dotnet package remove.

When more logic is shared with NuGet, more package-related commands can be implemented and some of the logic here simplified. See NuGet/Home#14390.

I've modeled the commands around their project-based counterparts. package remove is relatively simple - corresponding #:package directives are removed from the C# file, Directory.Packages.props is not considered at all. package add needs to update Directory.Packages.props if Central Package Management is enabled. It also needs to perform restore. If no version is specified by the user on the command-line, it needs to determine the latest version and put that into the package reference directive.

@jjonescz jjonescz added the Area-run-file Items related to the "dotnet run <file>" effort label Jul 2, 2025
@jjonescz jjonescz marked this pull request as ready for review July 2, 2025 17:02
@jjonescz jjonescz requested review from Copilot and a team July 2, 2025 17:02
Copy link
Contributor

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR extends the dotnet package commands to support file-based C# apps by introducing a --file option, updating parsing/completion, and adding source‐editor logic to manipulate #:package directives.

  • Introduces --file alongside --project in parsers and completion scripts
  • Implements add/remove of #:package directives in FileBasedAppSourceEditor and wiring in PackageAddCommand / PackageRemoveCommand
  • Adds extensive unit tests and updates documentation for the new file-based scenarios

Reviewed Changes

Copilot reviewed 57 out of 57 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/Cli/dotnet/Commands/Package/PackageCommandParser.cs Adds FileOption and path‐processing logic for file-based apps
src/Cli/dotnet/Commands/Package/Add/PackageAddCommand.cs Implements file‐based add logic, including CPM and restore support
src/Cli/dotnet/Commands/Package/Remove/PackageRemoveCommand.cs Implements file‐based remove logic and reports removed directives
src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs Refactors diagnostics handling and directive loading for files
src/Cli/dotnet/Commands/Run/FileBasedAppSourceEditor.cs New editor for inserting/removing #:package directives
src/Cli/dotnet/Commands/Run/RunCommand.cs Updates error message constant for invalid option combinations
Comments suppressed due to low confidence (1)

src/Cli/dotnet/Commands/Run/FileBasedAppSourceEditor.cs:105

  • Typo in variable name addAfer—it should be spelled addAfter to match the intended meaning.
        CSharpDirective? addAfer = null;

@jjonescz
Copy link
Member Author

jjonescz commented Jul 9, 2025

@dotnet/run-file for reviews, thanks

}
}

{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know you can make arbitrary scopes like this, but what is this for?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This allows me to define the void Update(string value) local function twice - otherwise the nested one would "see" the parent one and complain it's re-declared.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would say that this is rather confusing: consider either using different names, such as UpdateInVersion/UpdateInMetadata, or combining the local function into one that takes an extra parameter, and then wrapping them in a lambda at the return to pass either versionAttribute or metadata.

@jjonescz jjonescz requested a review from a team July 11, 2025 09:40
@jjonescz
Copy link
Member Author

@RikkiGibson @chsienki for reviews, thanks

@jjonescz jjonescz requested review from jaredpar and RikkiGibson July 15, 2025 07:41
@jaredpar jaredpar self-assigned this Jul 15, 2025
@jjonescz
Copy link
Member Author

@RikkiGibson @jaredpar for another look, thanks

@jjonescz
Copy link
Member Author

@RikkiGibson @333fred for reviews, thanks

}
if (_arguments.Count != 1)

var packageToRemove = arguments.Single();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: can you just index? Or call First? No need for assertions around the length, we just checked that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can use pattern matching. Thanks.

trailingNewLines = 0;
}

bool isEndOfLine = trivia.IsKind(SyntaxKind.EndOfLineTrivia);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sure you're testing with a documentation comment at the top of the file, as well as things that will generate SkippedTokensTrivia. See https://github.com/dotnet/razor/blob/main/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/RoslynCSharpTokenizer.cs#L503 and surrounding cases for some examples.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there tests for files with comments at the top?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, see test Comments.

"""
/* test */

#:package [email protected]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a bit subjective, but I'd rather see:

#:package MyPackage@1.0.0

/* test */ Console.WriteLine();

Copy link
Member

@RikkiGibson RikkiGibson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haven't gotten all the way through yet, but, will finish tomorrow and also get through the other PRs #49769 #49807 #49808.

@@ -18,7 +16,7 @@ public static class PackageAddCommandParser
.AddCompletions((context) =>
{
// we should take --prerelease flags into account for version completion
var allowPrerelease = context.ParseResult.GetValue(PrereleaseOption);
var allowPrerelease = context.ParseResult.GetValue(PrereleaseOption!);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the order of the declarations is the only reason the suppressions are needed. That seems unfortunate.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, I can reorder, thanks.

if (!hasVersion)
{
skipUpdate = true;
return (Revert: NoOp, Update: Unreachable, Save: Revert);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am finding the flow here pretty wonky. It feels like some of the cases update the file on disk before we know whether that's what we should really do, and other cases wait to confirm that's what we should do before doing the update. Does it have to be that way?

I was also kinda hoping that the case where we need to update a Directory.Packages.props file could share more with the project-based version of the command. I guess that is not practical to do.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that once we have NuGet/Home#14390, lots of this logic can go away. But I didn't want to block the basic experience on that.

? (projectOrFile, AppKinds.Any)
: (Environment.CurrentDirectory, AppKinds.ProjectBased),
(true, false) => (parseResult.GetValue(FileOption)!, AppKinds.FileBased),
(false, true) => (parseResult.GetValue(ProjectOption)!, AppKinds.ProjectBased),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So is the "optionality" of an Option<T> indicated by giving it a nullable type argument? I am just curious if something would change for the worse here if FileOption and ProjectOption were Option<string>.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It wouldn't make a difference, the GetValue returns nullable T no matter what the original T is:

public T? GetValue<T>(Option<T> option)

return new FileBasedAppSourceEditor
{
SourceFile = sourceFile,
Directives = LoadDirectives(sourceFile),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: if Directives is just initialized based on value of SourceFile, would it be cleaner to delete the setter for it and just rely on it being initialized on access?


public void Add(CSharpDirective directive)
{
TextSpan span = DetermineWhereToAdd(directive, out var append);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Roslyn has a type TextChange, which consists of a TextSpan and string NewText, and SourceText.WithChanges() can take them to created edited versions of source files. Maybe it would be a bit cleaner to use that here?


// Find the last directive of the first group of directives of the same kind.
// If found, we will insert the new directive after it.
CSharpDirective? addAfer = null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addAfter?

// Otherwise, we will add the directive to the top of the file.
int start = 0;

var tokenizer = VirtualProjectBuildingCommand.CreateTokenizer(SourceFile.Text);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, this is an infrequent enough occurrence, that it would be fine to just parse, if it would make this implementation simpler. However, there's no strong need to make a change to do that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that would be any simpler - we would get the same kind of result (syntax trivia) to work on, and parsing would just do more work than lexing does.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Area-run-file Items related to the "dotnet run <file>" effort
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants